A

开了 React Compiler 之后:那些被 ESLint 拦下的 React Native 代码

2026-06-17 22:37

以前我们手写 useMemouseCallback 来“讨好”React 的渲染机制;React Compiler 想把这件事彻底接管。代价是:它要求你的代码足够“干净、可预测”——而它强制你做到这一点的方式,就是一连串看起来很烦的 ESLint 报错。

最近在一个 Expo / React Native 项目里,我把 app.jsonexperiments.reactCompiler 打开之后,编辑器里突然冒出一大堆红线。这些报错乍看像是 TypeScript 类型错误,点进去才发现全是 eslint-plugin-react-hooks 的新规则。

折腾下来我意识到:这些报错不是来找茬的,它们每一条都在把你往“React Compiler 能安全优化的写法”上推。这篇就把我踩到的三个典型报错拆开讲讲——它们分别对应一种很常见的写法,理解了原理,以后看到就知道该怎么改。

先搞清楚 React Compiler 在干嘛

传统 React 里,组件每次渲染都会重新执行函数体:重新创建对象、重新定义闭包、重新计算派生值。大部分时候这没问题,但一旦某个子组件用 React.memo 包了、或者某个值进了 useEffect 的依赖数组,这种“每次都是新引用”就会触发不必要的重渲染。

于是我们手动优化:

JSX
const handleClick = useCallback(() => doSomething(id), [id]); const sorted = useMemo(() => list.sort(cmp), [list]);

写多了你会发现,这其实是一种机械劳动——你在替编译器做本该自动完成的缓存判断,还经常写错依赖数组。

React Compiler(早期代号 Forget) 就是来接管这件事的。它是一个编译期工具(跑在 Babel/SWC 阶段),会分析你的组件,自动推断“哪个值依赖哪些输入、什么时候需要重算”,然后生成等价的、带缓存的代码。理想情况下,你再也不用手写 useMemo / useCallback

但它能这么做有个前提:你的组件必须符合 React 的规则——渲染是纯函数、副作用待在该待的地方、数据流可预测。否则编译器没法安全地推断缓存边界。

为了强制这个前提,新版 eslint-plugin-react-hooks(v6)带来了一批配套规则。我遇到的报错,全都出自这里。

报错一:别用 Effect 去“同步”派生状态

第一个红线出现在一个 VIP 订阅页。代码大概长这样:

JSX
const [tiers, setTiers] = useState(INITIAL_TIERS); // 从 IAP 拿到订阅信息后,把价格写回 tiers useEffect(() => { for (const sub of subscriptions) { if (sub.displayName === "Plus") { setTiers((pre) => pre.map((t) => (t.id === "plus" ? { ...t, price: `${sub.price}` } : t)) ); } } }, [subscriptions]);

报错:

react-hooks/set-state-in-effect
Calling setState synchronously within an effect can trigger cascading renders

为什么这是反模式

关键在于:tiers 根本不是一个“独立状态”,它是 subscriptions 算出来的派生值

用 effect 同步派生值,会走这样一条路径:

subscriptions 变化
  → 组件渲染一次(此时 tiers 还是旧的)
  → effect 执行 → setTiers
  → 组件又渲染一次(tiers 这才更新)

一次数据变化触发了两次渲染,中间还闪过一帧旧数据。这就是所谓的级联渲染(cascading render)。React 官方那篇 You Might Not Need an Effect 专门讲这件事——其实和 React Compiler 无关,是 React 一直以来的建议,只是 Compiler 把它升级成了硬性规则(因为级联渲染会让它的优化前提失效)。

正确写法:渲染期直接算

既然 tiers 是派生的,那就别存进 state,直接在渲染期算出来:

JSX
const priceMap = {}; for (const sub of subscriptions) { if (sub.displayName === "Plus") priceMap["plus"] = `${sub.price}`; } const tiers = INITIAL_TIERS.map((t) => priceMap[t.id] != null ? { ...t, price: priceMap[t.id] } : t );

subscriptions 一变,下次渲染时 tiers 自然就是新值。一次渲染搞定,没有中间态,也没有第二个 state 需要维护

报错二:开了 Compiler 就别再手写 useMemo

改完上面那段,我下意识地给派生计算包了个 useMemo(毕竟有个 for 循环,感觉应该缓存一下):

JSX
const tiers = useMemo(() => { const priceMap = {}; for (const sub of subscriptions) { /* ... */ } return INITIAL_TIERS.map(/* ... */); }, [subscriptions]);

结果又红了:

react-hooks/preserve-manual-memoization
Existing memoization could not be preserved
React Compiler has skipped optimizing this component

为什么手写 useMemo 反而坏事

这条报错背后的逻辑很有意思。React Compiler 看到你手写useMemo,它不会简单地无视,而是要先验证你的依赖数组写得对不对,再用它自己的缓存机制把这段替换掉。

但它有个保守的底线:如果它没法 100% 复刻你手写记忆化的行为,它宁可放弃优化整个组件,并报错提醒你。我那个 useMemo 里有 for 循环加上不断 mutate 一个 priceMap 对象,编译器分析依赖关系时觉得“我没把握复刻得一模一样”,于是直接跳过。

也就是说:一个写得不够规整的手动 useMemo,会让整个组件失去自动优化——反而比不写还糟。

正确写法:删掉 useMemo,写普通 const

JSX
// 不要 useMemo,普通计算即可,编译器会自动记忆化 const priceMap = {}; for (const sub of subscriptions) { /* ... */ } const tiers = INITIAL_TIERS.map(/* ... */);

这是开了 React Compiler 之后最需要扭转的直觉:绝大多数 useMemo / useCallback 都不该再手写了。你只管把计算写成干净的纯函数,缓存交给编译器。手写记忆化从“优化手段”变成了“可能挡路的东西”。

当然也有例外:如果某个值要传给一个没被 Compiler 编译的第三方组件、或者依赖项里有 Compiler 无法追踪的外部可变量,手写记忆化仍有意义。但默认心态应该是“先不写”。

报错三:和 Reanimated 的“可变”设计正面冲突

前两个报错都是我的写法确实有问题。但第三个不一样——它是 React Compiler 的假设和某个库的设计真冲突了。

场景是一个用 Reanimated 写的自定义下拉刷新组件。Reanimated 的核心是 useSharedValue,它返回一个对象,你通过给 .value 赋值来驱动动画:

JSX
const pullDownPosition = useSharedValue(0); // 在手势回调里直接改 .value,这是 Reanimated 的标准用法 pullDownPosition.value = withTiming(0, { duration: 180 });

打开 Compiler 后,几乎每一处 .value = 赋值都被标红:

react-hooks/immutability
This value cannot be modified
Modifying a value previously passed as an argument to a hook is not allowed

为什么这次是“误报”

React Compiler 的优化建立在一个假设上:被 hook 引用过的值是不可变的(这样它才能安全地缓存)。于是它把 useSharedValue 返回的对象也当成不可变的,看到 .value = 赋值就判定为“非法修改”。

可问题是,Reanimated 的 shared value 生来就是要被 .value = 修改的——这是它在 UI 线程驱动动画的整个工作方式。两边的世界观直接对撞了。

我还顺手验证了一下:这条规则和 react-hooks/exhaustive-deps 甚至是互相打架的——一个要求你把 shared value 放进依赖数组,另一个又禁止你修改“放进了依赖数组的值”。怎么改都满足不了。

处理方式:针对性关掉这条规则

既然是库设计层面的不兼容、且是误报,最干净的做法就是在 ESLint 配置里关掉这一条(其余 Compiler 规则全部保留):

JS
// eslint.config.js module.exports = defineConfig([ expoConfig, { rules: { // Reanimated 的 shared value 通过 `.value =` 修改是其设计模式, // 但 React Compiler 的 immutability 规则会把这些赋值误报为非法修改, // 与 Reanimated 根本冲突,故全局关闭该条规则。 "react-hooks/immutability": "off", }, }, ]);

这里的判断很重要:前两个报错是“我写错了,按规则改”;这一个是“规则错了,关掉它”。区分这两种情况,靠的是理解每条规则到底在保护什么——如果它保护的前提(不可变)在你的场景里本就不成立,那它就是误报。

小结:一张表 + 一句心法

把这三条(外加常见的 exhaustive-deps)放一起看:

规则它在管什么该怎么应对
set-state-in-effect别用 effect 同步派生状态渲染期直接算
preserve-manual-memoization开了 Compiler 就别手写 useMemo删掉,写普通 const
immutability误判 Reanimated 的 .value =对 Reanimated 关掉该规则
exhaustive-deps依赖数组要写全(仅 warning)视情况补依赖

这几条报错背后其实是同一件事:React Compiler 把性能优化从“你的工作”变成了“它的工作”,作为交换,它要求你的代码足够纯粹、可预测。

所以心法很简单——开了 Compiler 之后,你的注意力应该从“我要不要在这里加个 useMemo”转移到:

  • 这个值是不是派生的?是的话别存 state,直接算。
  • 这个副作用真的需要 effect 吗?还是渲染期就能完成?
  • 这个报错是我写错了,还是某个库的设计和 Compiler 的假设冲突了?

把代码写干净,优化交给编译器。这大概就是 React Compiler 时代写组件的新默认姿势。